Skip to content

Auto-detect and surface spawned agent servers#61

Open
sak0a wants to merge 49 commits into
mainfrom
feature/auto-detect-agent-servers
Open

Auto-detect and surface spawned agent servers#61
sak0a wants to merge 49 commits into
mainfrom
feature/auto-detect-agent-servers

Conversation

@sak0a

@sak0a sak0a commented May 13, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add server-side detection for agent-spawned processes using argv matching, stdout URL sniffing, PTY tracking, socket probing, and liveness checks.
  • Store detected servers in a validated registry and expose subscription and control RPCs through the WS/contracts layer.
  • Surface detected servers in the web UI with a branch-toolbar badge, drawer tab switch, server list panel, and xterm-backed log view.
  • Add unit and integration coverage for the detection pipeline, including Codex and real PTY server flows.
  • Update docs and release/package metadata to match the new s3 naming.

Testing

  • Not run locally.
  • Added coverage for ArgvHinter, Registry, StdoutSniffer, OS socket probes, and detected-server integration paths.

Summary by CodeRabbit

  • New Features

    • Auto-detects agent-spawned dev servers and adds a "Servers" UI: toolbar badge, Servers tab, per-server list, controls (open/copy/stop) and live log view; backend event stream and RPC to subscribe/stop/open.
  • Tests

    • Comprehensive unit/integration tests for detection pipeline, parsing, registry, probes, trackers, and UI components.
  • Documentation

    • Added design spec describing end-to-end auto-detect behavior and APIs.

Review Change Stack

sak0a added 30 commits May 13, 2026 03:53
Design covers detection across Codex, ACP (Claude/Cursor), and local PTY
sources via a hybrid argv-hint + stdout-regex + OS socket-probe pipeline.
UI surfaces detected servers as a Servers tab inside ThreadTerminalDrawer
with xterm.js log tailing, plus a count badge in BranchToolbar.
Adds subscribeDetectedServerEvents (streaming), detectedServers.stop,
and detectedServers.openInBrowser to WS_METHODS and WsRpcGroup in
packages/contracts/src/rpc.ts, plus stub handlers in apps/server/src/ws.ts
to satisfy the exhaustive HandlersFrom type check.
…xpansion

Replace loose endsWith check with basename equality so paths like
./node_modules/.bin/vite still match while snextflix/myremix do not,
limit positional fallback to the first 3 non-flag tokens, and add
comments explaining the one-level recursion guard against infinite loops.
… runtime escape

- Split SocketProbe.ts into SocketProbe.ts (tag/types only) and SocketProbeLive.ts
  (OS-selecting Layer). This breaks the circular dependency where the OS adapter
  files imported SocketProbe while SocketProbe.ts imported the adapters, causing
  ReferenceError at test runtime. Update server.ts to import from SocketProbeLive.ts.
- Capture the sniffer unsubscribe handle (unsubCandidate) returned by onCandidate()
  in both trackAgentCommand and trackPty, and call it in end() to prevent callbacks
  firing after the server has exited.
- Replace Effect.runPromise() escape-hatch calls inside sniffer/tracker callbacks
  with runFork() (Effect.runForkWith(context)), matching the established pattern
  used in terminal/Layers/Manager.ts for PTY data callbacks.
sak0a added 3 commits May 13, 2026 07:52
- Reformat the implementation plan and design spec
- Normalize examples and code snippets for readability
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

End-to-end detected-servers feature: adds contracts and WS RPCs, backend pipeline (argv hinting, stdout sniffer, OS socket probes, liveness), in-memory registry and service, ingress trackers for agent/PTY sources, runtime wiring, web UI/store/RPC client, shared LineBuffer, and comprehensive tests/fixtures.

Changes

Detected Servers Feature

Layer / File(s) Summary
Contracts & RPC
packages/contracts/src/detectedServers.ts, packages/contracts/src/rpc.ts, packages/contracts/src/index.ts
Adds detected-server schemas/types (ServerStatus, ServerSource, ServerFramework, ExitReason, DetectedServer, DetectedServerEvent) and RPC method definitions for subscription, stop, and open-in-browser.
Socket probe service and OS adapters
apps/server/src/detectedServers/Layers/SocketProbe.ts, SocketProbe.Linux.ts, SocketProbe.Darwin.ts, SocketProbe.Windows.ts, SocketProbeLive.ts, *.test.ts, __fixtures__/proc/*
Defines SocketProbe contract and provides Linux/Darwin/Windows implementations parsing /proc, lsof, or netstat to map listening sockets to { pid, port, host }, with tests and fixtures.
Stdout sniffing & liveness
apps/server/src/detectedServers/Layers/StdoutSniffer.ts, LivenessHeartbeat.ts, StdoutSniffer.test.ts, LivenessHeartbeat.test.ts, __fixtures__/stdout/*
Streaming parser that extracts URLs/ports/framework hints from stdout; liveness HEAD probe with timeout; fixtures and tests for multiple frameworks.
Argv hinter
apps/server/src/detectedServers/Layers/ArgvHinter.ts, ArgvHinter.test.ts
Function hintFromArgv to classify likely server commands from argv (handles runner indirection, deny tokens, special-cases) with tests and regressions.
Registry & service
apps/server/src/detectedServers/Layers/Registry.ts, Services/DetectedServerRegistry.ts, Registry.test.ts
In-memory registry managing DetectedServer lifecycle, identity indexing, event publication, and a Layer-wrapped DetectedServerRegistry service.
Ingress orchestration
apps/server/src/detectedServers/Layers/DetectedServersIngress.ts, DetectedServersIngress.test.ts, integration/*
DetectedServersIngressLive coordinates hinting, stdout sniffing, pid-tree resolution, socket probing, and liveness to progress servers through predicted→candidate→live/confirmed/exited; provides trackAgentCommand and trackPty. Integration tests exercise Codex and PTY flows.
PTY helpers & trackers
apps/server/src/detectedServers/PtyInputLineBuffer.ts, PtyCommandTracker.test.ts
Line-buffering and tokenizer for PTY input, feedCommand support for PTY trackers, and tests ensuring correct registration behavior.
Handlers & WS
apps/server/src/detectedServers/Handlers.ts, apps/server/src/ws.ts, server.test.ts, Handlers.test.ts
Handlers for stop/open-in-browser and new WS RPC handlers wiring registry subscription and control actions; tests and test-harness mocks updated.
Provider/adapter integration
apps/server/src/provider/... (Codex/Cursor layers, session runtime), AcpDetectedServersTap.ts, various adapter tests
Integration points: tracker usage in CodexSessionRuntime and CursorAdapter; AcpDetailSuffixDedup to forward only appended suffixes from ACP tool details; many tests updated to provide ingress stubs.
TerminalManager integration
apps/server/src/terminal/Layers/Manager.ts, Manager.test.ts
Terminal manager creates per-spawn PTY tracker, forwards PTY output, and ensures tracker lifecycle cleanup on exits and finalizers; tests updated to provide ingress stubs.
Runtime composition & dependency
apps/server/src/server.ts, apps/server/package.json, integration harness/tests
Composes DetectedServersLayerLive (socket probe, heartbeat, registry, ingress), adds pidtree dependency, wires into runtime layers and integration harness with mocks; adds Codex/PTy integration tests.
Web client: store, RPC client, and UI
apps/web/src/detectedServerStore.ts, rpc/wsRpcClient.ts, BranchToolbar.tsx, DetectedServersBadge.tsx, ChatView.tsx, ThreadTerminalDrawer.tsx, detectedServers/*, tests
Zustand store for servers and per-server line buffers (LineBuffer); WsRpcClient detectedServers methods; toolbar badge and servers panel UI (list, per-row actions, xterm log view), and client subscription wiring with tests.
Shared LineBuffer
packages/shared/src/lineBuffer.ts, lineBuffer.test.ts, package.json
New LineBuffer utility used for incremental log buffering with snapshot/trim behavior and package export.
Design doc
docs/superpowers/specs/2026-05-13-auto-detect-agent-servers-design.md
Comprehensive design spec mapping architecture, flows, state machine, RPC, UI, edge cases, and test plan.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🐰 I sniff the stdout, hop through trees,
Detect the ports and catch the little bees,
From predicted to live, the badge now glows,
Logs replay like carrots row by row,
Hooray — servers spotted, and the UI shows!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/auto-detect-agent-servers

@github-actions github-actions Bot added size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels May 13, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (7)
apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts (1)

88-111: 💤 Low value

Consider avoiding non-null assertion for clarity.

Line 110 uses a non-null assertion (!) after checking inodeToPid.has(r.inode) on line 109. While safe, using a more explicit pattern improves readability and type safety.

♻️ Optional refinement
     const rows = [...parseProcTcpRows(tcpText), ...parseProcTcp6Rows(tcp6Text)];
-    return rows
-      .filter((r) => r.state === "LISTEN" && inodeToPid.has(r.inode))
-      .map((r) => ({ pid: inodeToPid.get(r.inode)!, port: r.port, host: r.host }));
+    const results: ProbeResult[] = [];
+    for (const r of rows) {
+      if (r.state === "LISTEN") {
+        const pid = inodeToPid.get(r.inode);
+        if (pid !== undefined) results.push({ pid, port: r.port, host: r.host });
+      }
+    }
+    return results;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts` around lines 88
- 111, The code in probeImpl uses a non-null assertion inodeToPid.get(r.inode)!
after checking inodeToPid.has(r.inode); replace this with an explicit safe
lookup to avoid the bang: when building the result from rows, obtain const pid =
inodeToPid.get(r.inode) and only return entries when pid is defined (or use a
flatMap/filter that discards undefined pids) so the final map uses the real pid
variable instead of r.inode!; update references in the rows -> filter/map
pipeline (rows, inodeToPid, r.inode) accordingly to preserve types and
readability.
apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts (1)

5-29: 💤 Low value

Consider adding a comment for the parts.length - 1 slice.

Line 14 slices to parts.length - 1 to exclude the trailing "(LISTEN)" token from the name field. While correct, a brief inline comment would improve readability for future maintainers.

📝 Suggested comment
-    const nameField = parts.slice(8, parts.length - 1).join(" ");
+    // Exclude trailing "(LISTEN)" from name field
+    const nameField = parts.slice(8, parts.length - 1).join(" ");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts` around lines 5
- 29, Add a brief inline comment explaining why parts.slice(8, parts.length - 1)
excludes the trailing "(LISTEN)" token when building nameField in
parseLsofOutput; place the comment near the slice expression (in the
parseLsofOutput function) noting that the last token is "(LISTEN)" and should
not be included in the name/host:port parsing so future readers understand the
-1 adjustment.
apps/server/src/detectedServers/StdoutSniffer.test.ts (2)

94-100: ⚡ Quick win

Add length assertion before accessing out[0].

The ANSI stripping test accesses out[0]! without first asserting the array has at least one element. Add expect(out).toHaveLength(1); before line 99 for consistency with the chunked URL test above.

📋 Proposed fix
   it("strips ANSI before matching", () => {
     const sniffer = new StdoutSniffer();
     const out: { url: string }[] = [];
     sniffer.onCandidate((c) => out.push(c));
     sniffer.feed("\x1b[36m  ➜  Local:\x1b[0m \x1b[1mhttp://localhost:5173/\x1b[0m\n");
+    expect(out).toHaveLength(1);
     expect(out[0]!.url).toBe("http://localhost:5173/");
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/StdoutSniffer.test.ts` around lines 94 - 100,
In the "strips ANSI before matching" test for StdoutSniffer, add an assertion
that the out array has one element before accessing out[0]; specifically, insert
expect(out).toHaveLength(1) immediately after feeding the sniffer and before the
existing expect(out[0]!.url).toBe(...) so the test checks presence of the
candidate before indexing.

21-82: ⚡ Quick win

Add length assertions before accessing array elements.

Most tests directly access out[0]! without first asserting the array length. If the sniffer fails to detect a URL, the test will fail with an unclear "Cannot read properties of undefined" error instead of a descriptive assertion failure. The Vite test (line 15) demonstrates the correct pattern with expect(out).toHaveLength(1) before accessing elements.

📋 Proposed fix to add length checks
   it("extracts Next URL", () => {
     const sniffer = new StdoutSniffer();
     const out: { url: string; port: number; framework: string }[] = [];
     sniffer.onCandidate((c) => out.push(c));
     sniffer.feed(readFileSync(fixturePath("next"), "utf8"));
+    expect(out).toHaveLength(1);
     expect(out[0]!.url).toBe("http://localhost:3000");
     expect(out[0]!.framework).toBe("next");
   });

Apply the same pattern to Nuxt (line 30+), Astro (line 39+), Remix (line 48+), Wrangler (line 57+), Webpack (line 66+), and Express (line 75+) tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/StdoutSniffer.test.ts` around lines 21 - 82,
Tests for StdoutSniffer access out[0] without asserting the array length, which
can produce unclear errors; before any expect that reads out[0] (in the Nuxt,
Astro, Remix, Wrangler, Webpack and Express tests) add an assertion like
expect(out).toHaveLength(1) so the test fails with a clear message if no
candidate was emitted, then proceed to assert out[0]!.url / .port / .framework
as before.
apps/server/src/detectedServers/ArgvHinter.test.ts (1)

1-98: ⚡ Quick win

Add test coverage for shortcut invocation (yarn/pnpm/bun).

The code at lines 64-73 in ArgvHinter.ts supports shortcut invocations like yarn dev, pnpm dev, and bun dev (without the explicit run keyword). This code path is currently untested. Add test cases to verify this behavior.

📋 Suggested test cases
  it("treats npm run build as build, not server", () => {
    const got = hintFromArgv(["npm", "run", "build"], { scripts: { build: "vite build" } });
    expect(got).toEqual({ framework: "vite", isLikelyServer: false });
  });

+  it("expands yarn dev shortcut (without explicit run)", () => {
+    const got = hintFromArgv(["yarn", "dev"], { scripts: { dev: "vite" } });
+    expect(got).toEqual({ framework: "vite", isLikelyServer: true });
+  });
+
+  it("expands pnpm start shortcut", () => {
+    const got = hintFromArgv(["pnpm", "start"], { scripts: { start: "next dev" } });
+    expect(got).toEqual({ framework: "next", isLikelyServer: true });
+  });
+
+  it("expands bun serve shortcut", () => {
+    const got = hintFromArgv(["bun", "serve"], { scripts: { serve: "astro dev" } });
+    expect(got).toEqual({ framework: "astro", isLikelyServer: true });
+  });

  // Regression tests: prefix-match false positives
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/ArgvHinter.test.ts` around lines 1 - 98, Add
tests covering shortcut invocations (yarn/pnpm/bun) to exercise hintFromArgv's
branch for commands like "yarn dev" (no "run"); specifically add cases that call
hintFromArgv(["yarn","dev"], {scripts:{dev:"vite"}}),
hintFromArgv(["pnpm","dev"], {scripts:{dev:"vite"}}) and
hintFromArgv(["bun","dev"], {scripts:{dev:"vite"}}) and assert they return {
framework: "vite", isLikelyServer: true }, plus at least one build shortcut test
(e.g. ["yarn","build"] with scripts.build "vite build") asserting { framework:
"vite", isLikelyServer: false }; reference hintFromArgv in ArgvHinter.test.ts
and add the new it(...) blocks alongside the existing tests.
apps/web/src/components/BranchToolbar.tsx (1)

134-134: ⚡ Quick win

Avoid creating a new function on every render.

The pattern onClick={onOpenServersTab ?? (() => {})} creates a new no-op function each time BranchToolbar re-renders when onOpenServersTab is undefined. If DetectedServersBadge is memoized, this will cause unnecessary child re-renders.

Suggested refactor

Option 1: Use a stable NOOP constant

+const NOOP = () => {};
+
 export const BranchToolbar = memo(function BranchToolbar({
   ...
 }: BranchToolbarProps) {
   ...
-        <DetectedServersBadge servers={detectedServers} onClick={onOpenServersTab ?? (() => {})} />
+        <DetectedServersBadge servers={detectedServers} onClick={onOpenServersTab ?? NOOP} />

Option 2: Make onClick optional in DetectedServersBadge and conditionally render the badge

-        <DetectedServersBadge servers={detectedServers} onClick={onOpenServersTab ?? (() => {})} />
+        {onOpenServersTab && <DetectedServersBadge servers={detectedServers} onClick={onOpenServersTab} />}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/BranchToolbar.tsx` at line 134, BranchToolbar
currently passes onClick={onOpenServersTab ?? (() => {})}, which allocates a new
noop on every render and can break DetectedServersBadge memoization; fix by
using a stable NOOP constant (e.g., define a module-level NOOP and pass that
instead) or make DetectedServersBadge's onClick prop optional and only pass
onOpenServersTab when it's defined (or conditionally render the badge),
referencing DetectedServersBadge and the onOpenServersTab prop in BranchToolbar
to locate the change.
apps/web/src/terminalStateStore.ts (1)

758-855: ⚡ Quick win

Keep terminalDrawerKindByThreadKey in sync with terminal state cleanup

Line 758 onward clears/removes thread-scoped terminal state, launch context, and event buffers, but leaves terminalDrawerKindByThreadKey untouched. This creates stale per-thread UI state and unbounded growth over time.

Suggested fix
         clearTerminalState: (threadRef) =>
           set((state) => {
             const threadKey = terminalThreadKey(threadRef);
@@
             const { [threadKey]: _removed, ...remainingLaunchContexts } =
               state.terminalLaunchContextByThreadKey;
+            const { [threadKey]: _removedDrawerKind, ...remainingDrawerKinds } =
+              state.terminalDrawerKindByThreadKey;
@@
             return {
               terminalStateByThreadKey: nextTerminalStateByThreadKey,
               terminalLaunchContextByThreadKey: remainingLaunchContexts,
+              terminalDrawerKindByThreadKey: remainingDrawerKinds,
               terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
             };
           }),
         removeTerminalState: (threadRef) =>
           set((state) => {
@@
             const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey };
             delete nextLaunchContexts[threadKey];
+            const nextDrawerKinds = { ...state.terminalDrawerKindByThreadKey };
+            delete nextDrawerKinds[threadKey];
             return {
               terminalStateByThreadKey: nextTerminalStateByThreadKey,
               terminalLaunchContextByThreadKey: nextLaunchContexts,
+              terminalDrawerKindByThreadKey: nextDrawerKinds,
               terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
             };
           }),
         removeOrphanedTerminalStates: (activeThreadKeys) =>
           set((state) => {
@@
             const orphanedLaunchContextIds = Object.keys(
               state.terminalLaunchContextByThreadKey,
             ).filter((key) => !activeThreadKeys.has(key));
+            const orphanedDrawerKindIds = Object.keys(
+              state.terminalDrawerKindByThreadKey,
+            ).filter((key) => !activeThreadKeys.has(key));
@@
             if (
               orphanedIds.length === 0 &&
               orphanedLaunchContextIds.length === 0 &&
+              orphanedDrawerKindIds.length === 0 &&
               !removedEventEntries
             ) {
               return state;
             }
@@
             const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey };
             for (const id of orphanedLaunchContextIds) {
               delete nextLaunchContexts[id];
             }
+            const nextDrawerKinds = { ...state.terminalDrawerKindByThreadKey };
+            for (const id of orphanedDrawerKindIds) {
+              delete nextDrawerKinds[id];
+            }
             return {
               terminalStateByThreadKey: next,
               terminalLaunchContextByThreadKey: nextLaunchContexts,
+              terminalDrawerKindByThreadKey: nextDrawerKinds,
               terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
             };
           }),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/terminalStateStore.ts` around lines 758 - 855, The cleanup
functions clearTerminalState, removeTerminalState, and
removeOrphanedTerminalStates must also remove per-thread entries from
terminalDrawerKindByThreadKey to avoid stale UI state: for clearTerminalState
and removeTerminalState compute whether
state.terminalDrawerKindByThreadKey[threadKey] existed (hadDrawer) and create a
shallow copy nextDrawerKinds from state.terminalDrawerKindByThreadKey, delete
nextDrawerKinds[threadKey], include nextDrawerKinds in the returned object, and
include hadDrawer in the early-return condition; for
removeOrphanedTerminalStates compute orphaned drawer keys from
Object.keys(state.terminalDrawerKindByThreadKey) that are not in
activeThreadKeys, delete them from a copy nextDrawerKinds, include
nextDrawerKinds in the returned state, and include their presence in the
early-return check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/integration/detectedServersCodex.integration.test.ts`:
- Around line 42-45: Replace the fixed 100ms sleep by polling the registry until
the expected candidate appears or a timeout expires: repeatedly call
registry.getCurrent("thread-1") (using the existing registry.getCurrent symbol)
in a short sleep loop (instead of Effect.sleep(Duration.millis(100)) once) and
break as soon as current.length === 1, failing the test if the timeout is
reached; use Effect.sleep with a small interval and a reasonable overall timeout
to keep assertions deterministic in CI.

In `@apps/server/src/detectedServers/Layers/DetectedServersIngress.ts`:
- Around line 11-17: The argvHasInspect function currently only captures inspect
flags that include an explicit "=port" and therefore ignores bare flags like
"--inspect", "--inspect-brk", and "--inspect-wait" which default to port 9229;
update argvHasInspect to also detect those bare forms and push 9229 when matched
(either by extending the regex to allow an optional "=...(\d+)" capture and
defaulting to 9229 when no capture is present, or by adding an additional test
for /^\-\-inspect(?:-brk|-wait)?$/ that pushes 9229) so that all inspect forms
are included in the returned ports array.
- Around line 212-224: The probe loop in DetectedServersIngress is repeatedly
calling registry.registerOrUpdate with status: "confirmed" (see
registry.registerOrUpdate and the liveSeenAt sleep logic), causing duplicate
unchanged updates; change the logic to suppress re-emitting identical
"confirmed" patches unless something meaningful changed (status, port, host) or
a configured debounce/heartbeat interval has elapsed — e.g., read the current
registry entry (or track lastEmittedStatus/lastEmittedAt for the
identityKey/threadId) and only call registry.registerOrUpdate when the
status/patch differs or when enough time has passed to justify a refresh; keep
references to identityKey, source.threadId, matching.port/host and liveSeenAt in
the condition so existing behavior remains for real changes or periodic
heartbeats.

In `@apps/server/src/detectedServers/Layers/Registry.ts`:
- Around line 180-184: The publish method currently iterates listeners and a
thrown exception from one listener aborts delivery; update publish(threadId:
string, event: DetectedServerEvent) to wrap each listener invocation (for const
l of set) in a try/catch so a failing listener does not stop others and does not
propagate to the caller; inside the catch, log the error with contextual info
(e.g., include threadId and the listener identity) using the available logger
(or console.error if no logger exists) and continue delivering to remaining
listeners.
- Around line 61-72: getCurrent and findById expose live DetectedServer objects
from the internal byThread maps, allowing external callers to mutate registry
state and bypass registerOrUpdate checks; change both to return defensive copies
(e.g., map each DetectedServer to a shallow clone or Object.assign({}, server)
or JSON-deep-clone as appropriate for your fields) so callers get copies not
references, and keep registerOrUpdate operating on the original objects stored
in byThread; update getCurrent to return an array of cloned DetectedServer
objects and update findById to return a cloned DetectedServer or undefined.

In `@apps/server/src/provider/Layers/CodexSessionRuntime.ts`:
- Around line 800-814: Existing logic finalizes trackers only on
"item/completed", leaving trackers in trackerMap running if the session is
closed or interrupted; update the session shutdown/interrupt handler(s) in
CodexSessionRuntime to iterate over trackerMap, call tracker.end(...) with a
non-success terminal status (e.g., "error" or "cancel") for each tracker, and
then delete or clear those entries so no stale detection work remains; ensure
the same cleanup is also invoked from any early-exit/exception path so trackers
created in the "item/commandExecution/outputDelta" / commandExecution flow are
always finalized.

In `@apps/server/src/provider/Layers/CursorAdapter.ts`:
- Around line 777-806: The trackerMap is currently keyed only by toolCallId
which can collide across threads and the branch that creates a tracker when the
first event is terminal (status "completed" or "failed") never ends it; fix by
using a thread-scoped key (e.g., combine ctx.threadId and toolCallId) when
reading/writing trackerMap entries and, inside the block that creates a new
tracker via detectedServers.trackAgentCommand, check the incoming toolStatus: if
it is "completed" or "failed" immediately call tracker.end(...) with "success"
or "error" respectively and remove the tracker from trackerMap; keep feeding
detail when present and ensure trackerMap.delete is called for terminal cases.

In `@apps/server/src/ws.ts`:
- Around line 2046-2053: The subscription callback currently calls
Effect.runSync(Queue.offer(queue, event)) which blocks and can throw; change it
to run the offer effect non-blockingly by either invoking Effect.runCallback(()
=> Queue.offer(queue, event)) or by forking the effect
(Effect.fork(Queue.offer(queue, event))) so exceptions are handled inside the
effect runtime; locate the callback passed to detectedServerRegistry.subscribe
inside the liveStream creation and replace Effect.runSync with one of these
non-blocking patterns (or have the subscribe callback return the Effect directly
if the subscribe API supports effectful callbacks) to match the safer pattern
used elsewhere.

In `@apps/web/src/components/ChatView.tsx`:
- Around line 1059-1067: Effect currently reads a non-reactive snapshot via
readEnvironmentConnection inside useEffect so if it returns null initially the
subscription never retries; change the effect to derive a reactive connection
value (e.g. call readEnvironmentConnection(environmentId) at render time or
introduce a useEnvironmentConnection(environmentId) hook) and include that
connection in the useEffect deps, then only attach detectedServers.onEvent when
connection is non-null and ensure you clean up on return; this affects the
useEffect block that references readEnvironmentConnection, scopeThreadRef,
scopedThreadKey, detectedServers.onEvent and
useDetectedServerStore.getState().handleEvent so that subscriptions retry
automatically when the environment connection becomes available.

In `@apps/web/src/components/detectedServers/DetectedServerRow.tsx`:
- Around line 25-83: The row currently uses a outer <button> (wrapping the
entire row) while also rendering child <button>s (onOpen, onCopy, onStop), which
creates nested interactive elements; change the outer element in
DetectedServerRow to a non-interactive container (e.g., <div>) with
role="button", tabIndex={0}, the same className logic, onClick={onSelect} and an
onKeyDown handler that triggers onSelect for Enter/Space so keyboard users still
activate the row; keep the inner buttons as real <button>s, preserve
e.stopPropagation() in their onClick handlers, and ensure the aria-labels and
disabled logic for onStop remain unchanged so accessibility and behavior are
preserved.

In `@apps/web/src/components/detectedServers/DetectedServersPanel.tsx`:
- Around line 30-49: Wrap the external async calls in handleStop, handleOpen and
handleCopy with try/catch so RPC/clipboard failures are handled: in handleStop
and handleOpen, await the RPC call inside a try block and catch errors to log
them (process via console.error or a UI error toast) and optionally show a
user-friendly message; in handleCopy, await navigator.clipboard.writeText(url)
inside try/catch and handle permission/clipboard errors similarly; ensure you
return early if prerequisites (threadRef/connection) are missing, and avoid
unhandled promise rejections by not using fire-and-forget (remove the void
usage).

In `@docs/superpowers/specs/2026-05-13-auto-detect-agent-servers-design.md`:
- Line 488: The file-level summary references the wrong WS handler path
(apps/server/src/wsServer.ts) while the PR stack context and RPC handlers live
under apps/server/src/ws.ts; update the summary to match the actual file name
used by the RPC handlers (change apps/server/src/wsServer.ts →
apps/server/src/ws.ts) or, if the canonical file is wsServer.ts, update the
stack/context references to that name so both the summary and the RPC handler
context (ws.ts / wsServer.ts) are consistent and navigable.
- Line 71: Several fenced code blocks in the design doc are missing language
identifiers causing MD040 lint failures; update the fences at the listed
locations (Line 71, 94, 126, 168, 180, 248, 355, 470) to include the appropriate
language tags as shown in the suggested patch (e.g., use ```text for directory
listings and UI diagrams, ```ts for TypeScript snippets like
DetectedServerEvent, ServerStatus and the serversByThreadKey record, and
```regex for the URL regex), and ensure each closing fence remains ``` so the
blocks are properly recognized by the linter.

In `@packages/contracts/src/detectedServers.ts`:
- Around line 71-82: The patch schema for updates currently allows non-positive
port and pid values; update the patch Schema.Struct (the "patch" property in the
UpdatedEvent) to apply the same isGreaterThan(0) constraint used in the base
DetectedServer schema for the port and pid fields so patched values cannot be
zero or negative (match the validation on DetectedServer.port and
DetectedServer.pid).

---

Nitpick comments:
In `@apps/server/src/detectedServers/ArgvHinter.test.ts`:
- Around line 1-98: Add tests covering shortcut invocations (yarn/pnpm/bun) to
exercise hintFromArgv's branch for commands like "yarn dev" (no "run");
specifically add cases that call hintFromArgv(["yarn","dev"],
{scripts:{dev:"vite"}}), hintFromArgv(["pnpm","dev"], {scripts:{dev:"vite"}})
and hintFromArgv(["bun","dev"], {scripts:{dev:"vite"}}) and assert they return {
framework: "vite", isLikelyServer: true }, plus at least one build shortcut test
(e.g. ["yarn","build"] with scripts.build "vite build") asserting { framework:
"vite", isLikelyServer: false }; reference hintFromArgv in ArgvHinter.test.ts
and add the new it(...) blocks alongside the existing tests.

In `@apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts`:
- Around line 5-29: Add a brief inline comment explaining why parts.slice(8,
parts.length - 1) excludes the trailing "(LISTEN)" token when building nameField
in parseLsofOutput; place the comment near the slice expression (in the
parseLsofOutput function) noting that the last token is "(LISTEN)" and should
not be included in the name/host:port parsing so future readers understand the
-1 adjustment.

In `@apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts`:
- Around line 88-111: The code in probeImpl uses a non-null assertion
inodeToPid.get(r.inode)! after checking inodeToPid.has(r.inode); replace this
with an explicit safe lookup to avoid the bang: when building the result from
rows, obtain const pid = inodeToPid.get(r.inode) and only return entries when
pid is defined (or use a flatMap/filter that discards undefined pids) so the
final map uses the real pid variable instead of r.inode!; update references in
the rows -> filter/map pipeline (rows, inodeToPid, r.inode) accordingly to
preserve types and readability.

In `@apps/server/src/detectedServers/StdoutSniffer.test.ts`:
- Around line 94-100: In the "strips ANSI before matching" test for
StdoutSniffer, add an assertion that the out array has one element before
accessing out[0]; specifically, insert expect(out).toHaveLength(1) immediately
after feeding the sniffer and before the existing expect(out[0]!.url).toBe(...)
so the test checks presence of the candidate before indexing.
- Around line 21-82: Tests for StdoutSniffer access out[0] without asserting the
array length, which can produce unclear errors; before any expect that reads
out[0] (in the Nuxt, Astro, Remix, Wrangler, Webpack and Express tests) add an
assertion like expect(out).toHaveLength(1) so the test fails with a clear
message if no candidate was emitted, then proceed to assert out[0]!.url / .port
/ .framework as before.

In `@apps/web/src/components/BranchToolbar.tsx`:
- Line 134: BranchToolbar currently passes onClick={onOpenServersTab ?? (() =>
{})}, which allocates a new noop on every render and can break
DetectedServersBadge memoization; fix by using a stable NOOP constant (e.g.,
define a module-level NOOP and pass that instead) or make DetectedServersBadge's
onClick prop optional and only pass onOpenServersTab when it's defined (or
conditionally render the badge), referencing DetectedServersBadge and the
onOpenServersTab prop in BranchToolbar to locate the change.

In `@apps/web/src/terminalStateStore.ts`:
- Around line 758-855: The cleanup functions clearTerminalState,
removeTerminalState, and removeOrphanedTerminalStates must also remove
per-thread entries from terminalDrawerKindByThreadKey to avoid stale UI state:
for clearTerminalState and removeTerminalState compute whether
state.terminalDrawerKindByThreadKey[threadKey] existed (hadDrawer) and create a
shallow copy nextDrawerKinds from state.terminalDrawerKindByThreadKey, delete
nextDrawerKinds[threadKey], include nextDrawerKinds in the returned object, and
include hadDrawer in the early-return condition; for
removeOrphanedTerminalStates compute orphaned drawer keys from
Object.keys(state.terminalDrawerKindByThreadKey) that are not in
activeThreadKeys, delete them from a copy nextDrawerKinds, include
nextDrawerKinds in the returned state, and include their presence in the
early-return check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7b560b23-e3e1-4390-aaa6-096a979172ad

📥 Commits

Reviewing files that changed from the base of the PR and between 011ed26 and 306b964.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (65)
  • apps/server/integration/OrchestrationEngineHarness.integration.ts
  • apps/server/integration/detectedServersCodex.integration.test.ts
  • apps/server/integration/detectedServersPty.integration.test.ts
  • apps/server/package.json
  • apps/server/src/detectedServers/ArgvHinter.test.ts
  • apps/server/src/detectedServers/Layers/ArgvHinter.ts
  • apps/server/src/detectedServers/Layers/DetectedServersIngress.ts
  • apps/server/src/detectedServers/Layers/LivenessHeartbeat.ts
  • apps/server/src/detectedServers/Layers/Registry.ts
  • apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts
  • apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts
  • apps/server/src/detectedServers/Layers/SocketProbe.Windows.ts
  • apps/server/src/detectedServers/Layers/SocketProbe.ts
  • apps/server/src/detectedServers/Layers/SocketProbeLive.ts
  • apps/server/src/detectedServers/Layers/StdoutSniffer.ts
  • apps/server/src/detectedServers/Registry.test.ts
  • apps/server/src/detectedServers/Services/DetectedServerRegistry.ts
  • apps/server/src/detectedServers/SocketProbe.Darwin.test.ts
  • apps/server/src/detectedServers/SocketProbe.Linux.test.ts
  • apps/server/src/detectedServers/SocketProbe.Windows.test.ts
  • apps/server/src/detectedServers/StdoutSniffer.test.ts
  • apps/server/src/detectedServers/__fixtures__/proc/tcp.txt
  • apps/server/src/detectedServers/__fixtures__/proc/tcp6.txt
  • apps/server/src/detectedServers/__fixtures__/stdout/astro.txt
  • apps/server/src/detectedServers/__fixtures__/stdout/express.txt
  • apps/server/src/detectedServers/__fixtures__/stdout/next.txt
  • apps/server/src/detectedServers/__fixtures__/stdout/nuxt.txt
  • apps/server/src/detectedServers/__fixtures__/stdout/remix.txt
  • apps/server/src/detectedServers/__fixtures__/stdout/vite.txt
  • apps/server/src/detectedServers/__fixtures__/stdout/webpack.txt
  • apps/server/src/detectedServers/__fixtures__/stdout/wrangler.txt
  • apps/server/src/provider/Drivers/CodexDriver.ts
  • apps/server/src/provider/Drivers/CursorDriver.ts
  • apps/server/src/provider/Layers/CodexAdapter.test.ts
  • apps/server/src/provider/Layers/CodexAdapter.ts
  • apps/server/src/provider/Layers/CodexSessionRuntime.ts
  • apps/server/src/provider/Layers/CursorAdapter.test.ts
  • apps/server/src/provider/Layers/CursorAdapter.ts
  • apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
  • apps/server/src/provider/Layers/ProviderRegistry.test.ts
  • apps/server/src/server.test.ts
  • apps/server/src/server.ts
  • apps/server/src/terminal/Layers/Manager.test.ts
  • apps/server/src/terminal/Layers/Manager.ts
  • apps/server/src/ws.ts
  • apps/web/src/components/BranchToolbar.tsx
  • apps/web/src/components/BranchToolbar/DetectedServersBadge.test.tsx
  • apps/web/src/components/BranchToolbar/DetectedServersBadge.tsx
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/ThreadTerminalDrawer.tsx
  • apps/web/src/components/detectedServers/DetectedServerLogView.tsx
  • apps/web/src/components/detectedServers/DetectedServerRow.tsx
  • apps/web/src/components/detectedServers/DetectedServersPanel.tsx
  • apps/web/src/detectedServerStore.test.ts
  • apps/web/src/detectedServerStore.ts
  • apps/web/src/rpc/wsRpcClient.ts
  • apps/web/src/terminalStateStore.ts
  • docs/superpowers/plans/2026-05-13-auto-detect-agent-servers.md
  • docs/superpowers/specs/2026-05-13-auto-detect-agent-servers-design.md
  • packages/contracts/src/detectedServers.ts
  • packages/contracts/src/index.ts
  • packages/contracts/src/rpc.ts
  • packages/shared/package.json
  • packages/shared/src/lineBuffer.test.ts
  • packages/shared/src/lineBuffer.ts

Comment thread apps/server/integration/detectedServersCodex.integration.test.ts Outdated
Comment thread apps/server/src/detectedServers/Layers/DetectedServersIngress.ts
Comment thread apps/server/src/detectedServers/Layers/DetectedServersIngress.ts Outdated
Comment thread apps/server/src/detectedServers/Layers/Registry.ts
Comment thread apps/server/src/detectedServers/Layers/Registry.ts
Comment on lines +25 to +83
<button
type="button"
onClick={onSelect}
className={cn(
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent",
active && "bg-accent",
)}
>
<Server className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-1.5">
<span className="font-medium">{server.framework}</span>
<span className={cn("rounded px-1.5 py-0.5 text-[10px]", STATUS_PILL_CLASS[server.status])}>
{server.status}
</span>
</div>
<div className="truncate text-muted-foreground">{server.url ?? "—"}</div>
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100">
{server.url && (
<button
type="button"
aria-label="Open in browser"
className="rounded p-1 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
)}
{server.url && (
<button
type="button"
aria-label="Copy URL"
className="rounded p-1 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
aria-label="Stop"
disabled={server.status === "exited" || server.status === "crashed"}
className="rounded p-1 hover:bg-background disabled:opacity-30"
onClick={(e) => {
e.stopPropagation();
onStop();
}}
>
<Square className="h-3.5 w-3.5" />
</button>
</div>
</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid nested <button> elements in server rows

Line 25 wraps the whole row in a <button>, then Lines 45/58/70 add child <button> controls. Nested interactive elements are invalid and can break keyboard and click behavior.

Suggested fix
-export const DetectedServerRow = ({ server, active, onSelect, onOpen, onCopy, onStop }: Props) => (
-  <button
-    type="button"
-    onClick={onSelect}
+export const DetectedServerRow = ({ server, active, onSelect, onOpen, onCopy, onStop }: Props) => (
+  <div
+    role="button"
+    tabIndex={0}
+    onClick={onSelect}
+    onKeyDown={(e) => {
+      if (e.key === "Enter" || e.key === " ") {
+        e.preventDefault();
+        onSelect();
+      }
+    }}
     className={cn(
       "group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent",
       active && "bg-accent",
     )}
   >
@@
-  </button>
+  </div>
 );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/detectedServers/DetectedServerRow.tsx` around lines
25 - 83, The row currently uses a outer <button> (wrapping the entire row) while
also rendering child <button>s (onOpen, onCopy, onStop), which creates nested
interactive elements; change the outer element in DetectedServerRow to a
non-interactive container (e.g., <div>) with role="button", tabIndex={0}, the
same className logic, onClick={onSelect} and an onKeyDown handler that triggers
onSelect for Enter/Space so keyboard users still activate the row; keep the
inner buttons as real <button>s, preserve e.stopPropagation() in their onClick
handlers, and ensure the aria-labels and disabled logic for onStop remain
unchanged so accessibility and behavior are preserved.

Comment on lines +30 to +49
const handleStop = async (serverId: string) => {
if (!threadRef) return;
const connection = readEnvironmentConnection(threadRef.environmentId);
if (!connection) return;
const result = await connection.client.detectedServers.stop({ serverId });
if (result.kind === "not-stoppable") {
console.info("Server managed by agent — interrupt the turn to stop it");
}
};

const handleCopy = (url: string) => {
void navigator.clipboard.writeText(url);
};

const handleOpen = async (serverId: string) => {
if (!threadRef) return;
const connection = readEnvironmentConnection(threadRef.environmentId);
if (!connection) return;
await connection.client.detectedServers.openInBrowser({ serverId });
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle RPC/clipboard failures in action handlers

Lines 30–49 issue external async calls (stop, openInBrowser, clipboard write) without try/catch. In this fire-and-forget wiring, failures become unhandled and users get no feedback.

Suggested fix
   const handleStop = async (serverId: string) => {
     if (!threadRef) return;
     const connection = readEnvironmentConnection(threadRef.environmentId);
     if (!connection) return;
-    const result = await connection.client.detectedServers.stop({ serverId });
-    if (result.kind === "not-stoppable") {
-      console.info("Server managed by agent — interrupt the turn to stop it");
+    try {
+      const result = await connection.client.detectedServers.stop({ serverId });
+      if (result.kind === "not-stoppable") {
+        console.info("Server managed by agent — interrupt the turn to stop it");
+      }
+    } catch {
+      console.error("Failed to stop detected server");
     }
   };
 
   const handleCopy = (url: string) => {
-    void navigator.clipboard.writeText(url);
+    void navigator.clipboard.writeText(url).catch(() => {
+      console.error("Failed to copy server URL");
+    });
   };
 
   const handleOpen = async (serverId: string) => {
     if (!threadRef) return;
     const connection = readEnvironmentConnection(threadRef.environmentId);
     if (!connection) return;
-    await connection.client.detectedServers.openInBrowser({ serverId });
+    try {
+      await connection.client.detectedServers.openInBrowser({ serverId });
+    } catch {
+      console.error("Failed to open detected server in browser");
+    }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/detectedServers/DetectedServersPanel.tsx` around
lines 30 - 49, Wrap the external async calls in handleStop, handleOpen and
handleCopy with try/catch so RPC/clipboard failures are handled: in handleStop
and handleOpen, await the RPC call inside a try block and catch errors to log
them (process via console.error or a UI error toast) and optionally show a
user-friendly message; in handleCopy, await navigator.clipboard.writeText(url)
inside try/catch and handle permission/clipboard errors similarly; ensure you
return early if prerequisites (threadRef/connection) are missing, and avoid
unhandled promise rejections by not using fire-and-forget (remove the void
usage).


### Module layout

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add language identifiers to fenced code blocks to satisfy markdown lint.

The fences at Line 71, Line 94, Line 126, Line 168, Line 180, Line 248, Line 355, and Line 470 are missing language tags (MD040), which is a CI/lint issue for docs quality gates.

🛠️ Suggested patch
-```
+```text
 apps/server/src/detectedServers/
   Services/
...
-```
+```

-```
+```text
 Agent tool call (Codex/ACP)        Local PTY (terminal/OpenCode)
...
-```
+```

-```
+```text
 predicted → candidate → confirmed → live → (restarting → live | exited | crashed)
...
-```
+```

-```
+```ts
 DetectedServerEvent =
   | { type: "registered"; server: DetectedServer }
...
-```
+```

-```
+```ts
 ServerStatus = "predicted" | "candidate" | "confirmed" | "live"
...
-```
+```

-```
+```regex
 \bhttps?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::\d+)?(?:/\S*)?\b
-```
+```

-```
+```ts
 {
   serversByThreadKey: Record<threadKey, Map<serverId, DetectedServer>>
...
-```
+```

-```
+```text
 apps/server/src/detectedServers/
   Services/DetectedServerRegistry.ts                       (new)
...
-```
+```

Also applies to: 94-94, 126-126, 168-168, 180-180, 248-248, 355-355, 470-470

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 71-71: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-05-13-auto-detect-agent-servers-design.md` at
line 71, Several fenced code blocks in the design doc are missing language
identifiers causing MD040 lint failures; update the fences at the listed
locations (Line 71, 94, 126, 168, 180, 248, 355, 470) to include the appropriate
language tags as shown in the suggested patch (e.g., use ```text for directory
listings and UI diagrams, ```ts for TypeScript snippets like
DetectedServerEvent, ServerStatus and the serversByThreadKey record, and
```regex for the URL regex), and ensure each closing fence remains ``` so the
blocks are properly recognized by the linter.


apps/server/src/
serverLayers.ts (modified: + ingress)
wsServer.ts (modified: + RPC handlers)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Verify WS handler filename in the file-level summary.

Line 488 lists apps/server/src/wsServer.ts, but the stack context for this PR layer references apps/server/src/ws.ts for WebSocket RPC handlers. Please align this path to avoid navigation confusion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-05-13-auto-detect-agent-servers-design.md` at
line 488, The file-level summary references the wrong WS handler path
(apps/server/src/wsServer.ts) while the PR stack context and RPC handlers live
under apps/server/src/ws.ts; update the summary to match the actual file name
used by the RPC handlers (change apps/server/src/wsServer.ts →
apps/server/src/ws.ts) or, if the canonical file is wsServer.ts, update the
stack/context references to that name so both the summary and the RPC handler
context (ws.ts / wsServer.ts) are consistent and navigable.

Comment on lines +71 to +82
patch: Schema.Struct({
status: Schema.optional(ServerStatus),
framework: Schema.optional(ServerFramework),
url: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int),
host: Schema.optional(Schema.String),
pid: Schema.optional(Schema.Int),
liveAt: Schema.optional(Schema.DateTimeUtc),
lastSeenAt: Schema.optional(Schema.DateTimeUtc),
exitedAt: Schema.optional(Schema.DateTimeUtc),
exitReason: Schema.optional(ExitReason),
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Inconsistent validation constraints in patch fields.

The port and pid fields in the UpdatedEvent patch lack the isGreaterThan(0) constraint present in the base DetectedServer schema (lines 43, 45). This allows patches with zero or negative values that would violate the main schema's invariants.

🛡️ Proposed fix to add matching constraints
   patch: Schema.Struct({
     status: Schema.optional(ServerStatus),
     framework: Schema.optional(ServerFramework),
     url: Schema.optional(Schema.String),
-    port: Schema.optional(Schema.Int),
+    port: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
     host: Schema.optional(Schema.String),
-    pid: Schema.optional(Schema.Int),
+    pid: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
     liveAt: Schema.optional(Schema.DateTimeUtc),
     lastSeenAt: Schema.optional(Schema.DateTimeUtc),
     exitedAt: Schema.optional(Schema.DateTimeUtc),
     exitReason: Schema.optional(ExitReason),
   }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
patch: Schema.Struct({
status: Schema.optional(ServerStatus),
framework: Schema.optional(ServerFramework),
url: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int),
host: Schema.optional(Schema.String),
pid: Schema.optional(Schema.Int),
liveAt: Schema.optional(Schema.DateTimeUtc),
lastSeenAt: Schema.optional(Schema.DateTimeUtc),
exitedAt: Schema.optional(Schema.DateTimeUtc),
exitReason: Schema.optional(ExitReason),
}),
patch: Schema.Struct({
status: Schema.optional(ServerStatus),
framework: Schema.optional(ServerFramework),
url: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
host: Schema.optional(Schema.String),
pid: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
liveAt: Schema.optional(Schema.DateTimeUtc),
lastSeenAt: Schema.optional(Schema.DateTimeUtc),
exitedAt: Schema.optional(Schema.DateTimeUtc),
exitReason: Schema.optional(ExitReason),
}),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/src/detectedServers.ts` around lines 71 - 82, The patch
schema for updates currently allows non-positive port and pid values; update the
patch Schema.Struct (the "patch" property in the UpdatedEvent) to apply the same
isGreaterThan(0) constraint used in the base DetectedServer schema for the port
and pid fields so patched values cannot be zero or negative (match the
validation on DetectedServer.port and DetectedServer.pid).

claude and others added 10 commits May 13, 2026 21:27
Adds a terminalId field to DetectedServer so the registry knows which
TerminalManager session owns a PTY-sourced server. Manager.ts now passes
session.terminalId when calling trackPty, and the WS detectedServers.stop
handler closes the owning terminal session for source="pty" servers
instead of always returning not-stoppable.
Local PTYs run a shell, which ArgvHinter rightly classifies as not a
server, so v1 PTY detection was inert. This wires user input through a
small line buffer that tokenizes complete lines (Enter/CR) and forwards
them via a new PtyTracker.feedCommand. Each likely-server command spawns
its own sub-tracker (sniffer + probe fiber + registry entry), so a single
PTY can host multiple detected servers in sequence.

The line buffer handles backspace and Ctrl-C; pathological in-line
editing (cursor keys etc.) leaks bracket characters into the buffer but
ArgvHinter classifies them as unknown and skips registration, which is
acceptable for v1.
ACP sends `toolCall.detail` cumulatively — every ToolCallUpdated event
REPLACES the previous text with a longer version. Forwarding the full
detail to StdoutSniffer on every update made it re-parse lines we already
saw, emitting duplicate registry events.

Extracts the dedup as AcpDetailSuffixDedup so we can unit-test the
suffix-extraction policy in isolation, and wires it into the Cursor ACP
adapter to feed only the new suffix per toolCallId. Defensive guard for
the (unexpected) case where detail shrinks: reset the per-key length and
re-feed the full string.
…unSync

Replaces global Error throws inside Effect.tryPromise catch callbacks with
small Schema-tagged error classes per module (HeartbeatError, PidtreeError,
SocketProbeError). These errors are immediately swallowed by orElseSucceed,
so the change is cosmetic — it removes the TS36 globalErrorInEffectCatch
advisories.

Replaces the bare Effect.runSync inside the subscribeDetectedServerEvents
Stream.callback handler with Effect.runSyncWith bound to the captured
context, matching the canonical pattern used elsewhere for callback->Queue
bridging. Removes the TS32 runEffectInsideEffect advisory in ws.ts.
Server tests:
- LivenessHeartbeat.test.ts: stubs global fetch and verifies 200/500 → true,
  AbortError + generic network errors → false.
- DetectedServersIngress.test.ts: drives trackAgentCommand and trackPty against
  stubbed SocketProbe / LivenessHeartbeat layers; covers noop tracker for
  non-server commands, candidate emission from sniffed URLs, the live
  promotion path, and that end() interrupts the probe fiber so no further
  SocketProbe calls fire.
- Handlers.test.ts: extracts the WS detectedServers.stop /
  detectedServers.openInBrowser handler logic into Handlers.ts so it can be
  unit-tested in isolation. Covers stop with source=pty + terminalId →
  TerminalManager.close, stop on a non-pty server → not-stoppable,
  openInBrowser with/without a url, and the registry's subscribe + snapshot
  semantics that the ws.ts subscribeDetectedServerEvents handler relies on.

Web tests:
- DetectedServersPanel.test.tsx: extracts a pure DetectedServersPanelView
  taking plain props (the Zustand-backed wrapper stays as the production
  entrypoint) and renders it via renderToStaticMarkup. Covers empty state,
  one row per server, and active-row styling.
- DetectedServerRow.test.tsx: status-pill class table, hidden Open/Copy
  buttons when url is absent, and Stop button disabled for exited/crashed.
- useDetectedServersSubscription hook: extracts the pure subscribe helper
  out of the useEffect closure in ChatView.tsx and tests the subscribe →
  unsubscribe → re-subscribe sequence as the active thread changes, plus
  the no-op paths when environmentId/threadId are missing.
Without the brand casts, tsc rejected the new useDetectedServersSubscription
helper and its test on three lines (TS2345), which broke CI. The pattern
matches the existing call sites in threadRoutes.ts that pass raw string
URL params into scopeThreadRef.
Support multi-command PTY tracking and improve detected servers
Resolves package rename @s3tools/ -> @ryco/ alongside new DetectedServers
service registrations.
- ws.ts: use Effect.runFork in detected-servers stream callback
- DetectedServersIngress: only emit a single "confirmed" probe update
- Registry: return defensive copies of DetectedServer state; isolate
  listener failures during publish fanout
- CodexSessionRuntime: finalize tracked agent commands on session close
- CursorAdapter: thread-scope tracker keys; end trackers when the
  first event is already terminal
- DetectedServerRow: replace outer <button> wrapper with role=button
  div to avoid nested interactives
- DetectedServersPanel: handle RPC/clipboard failures in action handlers
- contracts: tighten patch schema to require positive port/pid
- integration test: poll registry instead of fixed sleep
- docs: align spec with ws.ts filename
- rename remaining @s3tools/ imports to @ryco/ in detected-servers files
…m/sak0a/ryco into feature/auto-detect-agent-servers

# Conflicts:
#	apps/server/src/detectedServers/Layers/DetectedServersIngress.ts
#	apps/server/src/provider/Layers/CursorAdapter.ts
#	apps/server/src/ws.ts
#	apps/web/src/components/detectedServers/DetectedServersPanel.tsx
#	packages/contracts/src/detectedServers.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
apps/server/src/detectedServers/Layers/DetectedServersIngress.ts (1)

16-23: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bare --inspect flags are not currently captured—apply the suggested fix.

The argvHasInspect function only matches --inspect=PORT forms (with explicit =PORT). Bare flags like --inspect, --inspect-brk, and --inspect-wait without an explicit port are silently ignored, even though they should default to port 9229 per Node.js behavior. This breaks debugger port detection for common use cases.

The regex at line 19 should be updated to handle both forms:

Suggested fix
 const argvHasInspect = (argv: ReadonlyArray<string>): number[] => {
   const out: number[] = [];
   for (const t of argv) {
+    if (/^--inspect(?:-brk|-wait)?$/.test(t)) {
+      out.push(9229);
+      continue;
+    }
     const m = t.match(/--inspect(?:-brk|-wait)?=(?:[^:]*:)?(\d+)/);
     if (m) out.push(Number.parseInt(m[1]!, 10));
   }
   return out;
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/Layers/DetectedServersIngress.ts` around
lines 16 - 23, The argvHasInspect function currently only matches --inspect
forms with an explicit "=PORT"; update its pattern and logic in argvHasInspect
to also detect bare flags (--inspect, --inspect-brk, --inspect-wait) and treat
them as port 9229 when no port is provided: modify the regex used in
argvHasInspect to allow an optional "=(...)" capture and then if the capture is
missing/empty push 9229, otherwise parse the captured port number as before
(refer to argvHasInspect and the variable m in the loop).
🧹 Nitpick comments (1)
apps/server/src/detectedServers/DetectedServersIngress.test.ts (1)

76-77: 💤 Low value

Add length assertion before accessing array element.

The test accesses updated[0]! without first asserting updated.toHaveLength(1). If the test fails with an empty array, the error message will be less clear.

♻️ Clearer test structure
         const updated = yield* registry.getCurrent("t-pty");
+        expect(updated).toHaveLength(1);
         expect(updated[0]!.status === "candidate" || updated[0]!.status === "live").toBe(true);
         expect(updated[0]!.port).toBe(5173);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/DetectedServersIngress.test.ts` around lines
76 - 77, The test reads updated[0]! without asserting the array length, which
can cause unclear failures; add an explicit assertion like
expect(updated).toHaveLength(1) (or appropriate expected length) before
accessing updated[0]!, then keep the existing assertions on updated[0]!.status
and updated[0]!.port to ensure clear, early failure if the array is empty.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/src/detectedServers/Handlers.ts`:
- Around line 21-24: The handler currently swallows terminal close failures by
calling terminalManager.close(...).pipe(Effect.ignore({ log: true })) and then
returns { kind: "stopped" } which can report false success; remove the ignore
and instead let terminalManager.close(...) failures propagate (or explicitly map
them to an error case) so the handler only returns { kind: "stopped" } on
success; update the code around terminalManager.close, Effect.ignore, and the
return of { kind: "stopped" } to either await/return the effect directly or use
Effect.mapError to convert failures into a handler error path so failures are
not hidden.
- Around line 39-40: The call to open.openBrowser(server.url) is currently piped
to Effect.ignore({ log: true }) which swallows failures and causes the function
to always return { ok: true }; update the handler so it does not ignore runtime
failures: capture the effect result/exit of open.openBrowser (instead of piping
to Effect.ignore) and return { ok: false } when it fails (or rethrow the error)
and only return { ok: true } when the effect succeeds; locate the call to
open.openBrowser in Handlers.ts and replace the Effect.ignore usage with proper
error handling (e.g., inspect the Exit/Result or use Effect.map/Effect.catch to
convert failure into { ok: false }) so RPC/UI consumers get an accurate
success/failure response.

In `@apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts`:
- Around line 34-50: parseProcTcpRows (and similarly parseProcTcp6Rows)
currently uses non-null assertions on parts[1], parts[3], parts[9] and will
throw if a line is malformed; update the parsing to defensively validate each
line before using indices: verify parts.length is sufficient, that parts[1]
contains a ":" split, and that numeric/hex fields parse successfully (e.g.,
portHex, inode), and skip or continue on malformed lines (optionally log or
collect a warning) instead of throwing; ensure you still map state via STATE_MAP
and convert host via hexToIpv4 only after these checks so malformed input cannot
crash the probe.

In `@apps/server/src/provider/acp/AcpDetectedServersTap.ts`:
- Around line 21-30: The consume method assumes growth is always an append;
update consume (and use lengthByKey and maybe a stored prefix snapshot if
needed) to verify append semantics by checking whether the new detail startsWith
the previous prefix before slicing—if detail.length > previousLength but detail
does not startWith the previous prefix (or detail.length == previousLength but
content differs), treat it as a non-prefix rewrite: update the stored length and
return the full detail (i.e., re-feed whole content) instead of returning null
or a corrupted suffix so callers receive a consistent full update when append
semantics are violated.

---

Duplicate comments:
In `@apps/server/src/detectedServers/Layers/DetectedServersIngress.ts`:
- Around line 16-23: The argvHasInspect function currently only matches
--inspect forms with an explicit "=PORT"; update its pattern and logic in
argvHasInspect to also detect bare flags (--inspect, --inspect-brk,
--inspect-wait) and treat them as port 9229 when no port is provided: modify the
regex used in argvHasInspect to allow an optional "=(...)" capture and then if
the capture is missing/empty push 9229, otherwise parse the captured port number
as before (refer to argvHasInspect and the variable m in the loop).

---

Nitpick comments:
In `@apps/server/src/detectedServers/DetectedServersIngress.test.ts`:
- Around line 76-77: The test reads updated[0]! without asserting the array
length, which can cause unclear failures; add an explicit assertion like
expect(updated).toHaveLength(1) (or appropriate expected length) before
accessing updated[0]!, then keep the existing assertions on updated[0]!.status
and updated[0]!.port to ensure clear, early failure if the array is empty.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c547d4a6-41ee-494b-a41b-35d3351638d0

📥 Commits

Reviewing files that changed from the base of the PR and between 306b964 and 469936e.

📒 Files selected for processing (33)
  • apps/server/integration/OrchestrationEngineHarness.integration.ts
  • apps/server/integration/detectedServersCodex.integration.test.ts
  • apps/server/integration/detectedServersPty.integration.test.ts
  • apps/server/package.json
  • apps/server/src/detectedServers/DetectedServersIngress.test.ts
  • apps/server/src/detectedServers/Handlers.test.ts
  • apps/server/src/detectedServers/Handlers.ts
  • apps/server/src/detectedServers/Layers/ArgvHinter.ts
  • apps/server/src/detectedServers/Layers/DetectedServersIngress.ts
  • apps/server/src/detectedServers/Layers/LivenessHeartbeat.ts
  • apps/server/src/detectedServers/Layers/Registry.ts
  • apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts
  • apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts
  • apps/server/src/detectedServers/Layers/SocketProbe.Windows.ts
  • apps/server/src/detectedServers/Layers/StdoutSniffer.ts
  • apps/server/src/detectedServers/LivenessHeartbeat.test.ts
  • apps/server/src/detectedServers/PtyCommandTracker.test.ts
  • apps/server/src/detectedServers/PtyInputLineBuffer.ts
  • apps/server/src/detectedServers/Registry.test.ts
  • apps/server/src/detectedServers/Services/DetectedServerRegistry.ts
  • apps/server/src/provider/Drivers/CodexDriver.ts
  • apps/server/src/provider/Drivers/CursorDriver.ts
  • apps/server/src/provider/Layers/CodexAdapter.test.ts
  • apps/server/src/provider/Layers/CodexAdapter.ts
  • apps/server/src/provider/Layers/CodexSessionRuntime.ts
  • apps/server/src/provider/Layers/CursorAdapter.test.ts
  • apps/server/src/provider/Layers/CursorAdapter.ts
  • apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
  • apps/server/src/provider/Layers/ProviderRegistry.test.ts
  • apps/server/src/provider/acp/AcpDetectedServersTap.test.ts
  • apps/server/src/provider/acp/AcpDetectedServersTap.ts
  • apps/server/src/server.test.ts
  • apps/server/src/server.ts
✅ Files skipped from review due to trivial changes (2)
  • apps/server/src/detectedServers/LivenessHeartbeat.test.ts
  • apps/server/package.json
🚧 Files skipped from review as they are similar to previous changes (22)
  • apps/server/integration/OrchestrationEngineHarness.integration.ts
  • apps/server/integration/detectedServersCodex.integration.test.ts
  • apps/server/src/server.test.ts
  • apps/server/integration/detectedServersPty.integration.test.ts
  • apps/server/src/provider/Layers/CursorAdapter.ts
  • apps/server/src/provider/Drivers/CodexDriver.ts
  • apps/server/src/detectedServers/Registry.test.ts
  • apps/server/src/provider/Layers/CodexAdapter.test.ts
  • apps/server/src/provider/Drivers/CursorDriver.ts
  • apps/server/src/detectedServers/Layers/LivenessHeartbeat.ts
  • apps/server/src/provider/Layers/CodexAdapter.ts
  • apps/server/src/provider/Layers/CodexSessionRuntime.ts
  • apps/server/src/detectedServers/Layers/StdoutSniffer.ts
  • apps/server/src/detectedServers/Layers/Registry.ts
  • apps/server/src/detectedServers/Layers/SocketProbe.Darwin.ts
  • apps/server/src/detectedServers/Services/DetectedServerRegistry.ts
  • apps/server/src/provider/Layers/CursorAdapter.test.ts
  • apps/server/src/detectedServers/Layers/ArgvHinter.ts
  • apps/server/src/server.ts
  • apps/server/src/detectedServers/Layers/SocketProbe.Windows.ts
  • apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
  • apps/server/src/provider/Layers/ProviderRegistry.test.ts

Comment on lines +21 to +24
yield* terminalManager
.close({ threadId: server.threadId, terminalId: server.terminalId })
.pipe(Effect.ignore({ log: true }));
return { kind: "stopped" } as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t report stopped when terminal close fails.

Effect.ignore({ log: true }) swallows close failures, but the handler still returns { kind: "stopped" }. That can produce false success in the UI while the server is still running. Prefer propagating the error (or mapping it explicitly) instead of ignoring it.

Suggested fix
     if (server.source === "pty" && server.terminalId) {
-      yield* terminalManager
-        .close({ threadId: server.threadId, terminalId: server.terminalId })
-        .pipe(Effect.ignore({ log: true }));
+      yield* terminalManager.close({
+        threadId: server.threadId,
+        terminalId: server.terminalId,
+      });
       return { kind: "stopped" } as const;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
yield* terminalManager
.close({ threadId: server.threadId, terminalId: server.terminalId })
.pipe(Effect.ignore({ log: true }));
return { kind: "stopped" } as const;
yield* terminalManager.close({
threadId: server.threadId,
terminalId: server.terminalId,
});
return { kind: "stopped" } as const;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/Handlers.ts` around lines 21 - 24, The
handler currently swallows terminal close failures by calling
terminalManager.close(...).pipe(Effect.ignore({ log: true })) and then returns {
kind: "stopped" } which can report false success; remove the ignore and instead
let terminalManager.close(...) failures propagate (or explicitly map them to an
error case) so the handler only returns { kind: "stopped" } on success; update
the code around terminalManager.close, Effect.ignore, and the return of { kind:
"stopped" } to either await/return the effect directly or use Effect.mapError to
convert failures into a handler error path so failures are not hidden.

Comment on lines +39 to +40
yield* open.openBrowser(server.url).pipe(Effect.ignore({ log: true }));
return { ok: true } as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid returning { ok: true } if browser open fails.

This currently ignores runtime failures and still reports success, which is misleading for RPC/UI consumers. Return ok: false on failure (or propagate the error).

Suggested fix
-    yield* open.openBrowser(server.url).pipe(Effect.ignore({ log: true }));
-    return { ok: true } as const;
+    const opened = yield* open
+      .openBrowser(server.url)
+      .pipe(Effect.as(true as const), Effect.catchAll(() => Effect.succeed(false as const)));
+    return { ok: opened } as const;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
yield* open.openBrowser(server.url).pipe(Effect.ignore({ log: true }));
return { ok: true } as const;
const opened = yield* open
.openBrowser(server.url)
.pipe(Effect.as(true as const), Effect.catchAll(() => Effect.succeed(false as const)));
return { ok: opened } as const;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/Handlers.ts` around lines 39 - 40, The call
to open.openBrowser(server.url) is currently piped to Effect.ignore({ log: true
}) which swallows failures and causes the function to always return { ok: true
}; update the handler so it does not ignore runtime failures: capture the effect
result/exit of open.openBrowser (instead of piping to Effect.ignore) and return
{ ok: false } when it fails (or rethrow the error) and only return { ok: true }
when the effect succeeds; locate the call to open.openBrowser in Handlers.ts and
replace the Effect.ignore usage with proper error handling (e.g., inspect the
Exit/Result or use Effect.map/Effect.catch to convert failure into { ok: false
}) so RPC/UI consumers get an accurate success/failure response.

Comment on lines +34 to +50
export const parseProcTcpRows = (text: string): ProcTcpRow[] => {
const lines = text
.split("\n")
.slice(1)
.filter((l) => l.trim().length > 0);
return lines.map((line) => {
const parts = line.trim().split(/\s+/);
const [hostHex, portHex] = parts[1]!.split(":");
const state = STATE_MAP[parts[3]!] ?? parts[3]!;
return {
inode: Number.parseInt(parts[9]!, 10),
port: Number.parseInt(portHex!, 16),
host: hexToIpv4(hostHex!),
state,
};
});
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add defensive bounds checking in /proc parsers.

Both parseProcTcpRows and parseProcTcp6Rows use non-null assertions when accessing split results (e.g., parts[1]!, parts[3]!, parts[9]!) without verifying array length. If /proc/net/tcp or /proc/net/tcp6 format is unexpectedly malformed, these will throw runtime exceptions and crash the probe.

🛡️ Suggested defensive parsing
 export const parseProcTcpRows = (text: string): ProcTcpRow[] => {
   const lines = text
     .split("\n")
     .slice(1)
     .filter((l) => l.trim().length > 0);
-  return lines.map((line) => {
+  return lines.flatMap((line) => {
     const parts = line.trim().split(/\s+/);
+    if (parts.length < 10) return [];
     const [hostHex, portHex] = parts[1]!.split(":");
+    if (!hostHex || !portHex) return [];
     const state = STATE_MAP[parts[3]!] ?? parts[3]!;
     return {
       inode: Number.parseInt(parts[9]!, 10),
       port: Number.parseInt(portHex!, 16),
       host: hexToIpv4(hostHex!),
       state,
     };
   });
 };

Apply the same pattern to parseProcTcp6Rows.

Also applies to: 52-68

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/detectedServers/Layers/SocketProbe.Linux.ts` around lines 34
- 50, parseProcTcpRows (and similarly parseProcTcp6Rows) currently uses non-null
assertions on parts[1], parts[3], parts[9] and will throw if a line is
malformed; update the parsing to defensively validate each line before using
indices: verify parts.length is sufficient, that parts[1] contains a ":" split,
and that numeric/hex fields parse successfully (e.g., portHex, inode), and skip
or continue on malformed lines (optionally log or collect a warning) instead of
throwing; ensure you still map state via STATE_MAP and convert host via
hexToIpv4 only after these checks so malformed input cannot crash the probe.

Comment on lines +21 to +30
consume(key: string, detail: string): string | null {
const previousLength = this.lengthByKey.get(key) ?? 0;
if (detail.length < previousLength) {
this.lengthByKey.set(key, detail.length);
return detail;
}
if (detail.length > previousLength) {
this.lengthByKey.set(key, detail.length);
return detail.slice(previousLength);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle non-prefix rewrites in cumulative updates.

consume assumes every growth is a strict append. If detail is rewritten (same length or changed prefix), this can incorrectly return null or emit a corrupted suffix. Add a prefix check and fall back to full re-feed when append semantics are violated.

Proposed fix
 export class AcpDetailSuffixDedup {
   private readonly lengthByKey = new Map<string, number>();
+  private readonly lastDetailByKey = new Map<string, string>();
@@
   consume(key: string, detail: string): string | null {
+    const previousDetail = this.lastDetailByKey.get(key) ?? "";
     const previousLength = this.lengthByKey.get(key) ?? 0;
+
+    // unchanged
+    if (detail === previousDetail) return null;
+
+    // strict append
+    if (detail.length >= previousLength && detail.startsWith(previousDetail)) {
+      this.lengthByKey.set(key, detail.length);
+      this.lastDetailByKey.set(key, detail);
+      return detail.slice(previousLength);
+    }
+
+    // defensive fallback for shrink or rewrite
+    this.lengthByKey.set(key, detail.length);
+    this.lastDetailByKey.set(key, detail);
+    return detail;
-    if (detail.length < previousLength) {
-      this.lengthByKey.set(key, detail.length);
-      return detail;
-    }
-    if (detail.length > previousLength) {
-      this.lengthByKey.set(key, detail.length);
-      return detail.slice(previousLength);
-    }
-    return null;
   }
@@
   reset(key: string): void {
     this.lengthByKey.delete(key);
+    this.lastDetailByKey.delete(key);
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/provider/acp/AcpDetectedServersTap.ts` around lines 21 - 30,
The consume method assumes growth is always an append; update consume (and use
lengthByKey and maybe a stored prefix snapshot if needed) to verify append
semantics by checking whether the new detail startsWith the previous prefix
before slicing—if detail.length > previousLength but detail does not startWith
the previous prefix (or detail.length == previousLength but content differs),
treat it as a non-prefix rewrite: update the stored length and return the full
detail (i.e., re-feed whole content) instead of returning null or a corrupted
suffix so callers receive a consistent full update when append semantics are
violated.

sak0a added 5 commits May 14, 2026 11:44
These files were added by the upstream PR #63 merge and were not caught
by the earlier rename sweep, breaking apps/web typecheck on CI.
The selector returned a fresh array on every call which breaks Zustand
reference equality and triggers a render -> select -> render loop. Switch
to selecting the underlying Map and deriving the array via useMemo,
matching the pattern in DetectedServersPanel.tsx.
When a dev server briefly binds one port (e.g. 3000) during startup and
then settles on another (e.g. 5733), the probe could see both ports
listed in lsof for one iteration. The previous logic picked
candidates[0] and, on heartbeat failure, latched the entry into
'confirmed' with the wrong port and never updated.

Now: iterate candidates (sniffed port first when known) and pick the
one whose heartbeat succeeds. If none respond, fall back to the
preferred candidate as 'confirmed' but allow the port to change across
iterations until something actually responds.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx (2)

69-74: ⚡ Quick win

Remove placeholder test or implement proper handler verification.

This test verifies that renderToStaticMarkup doesn't call handlers—a documented behavior of the API. It adds no coverage value and the title acknowledges it's a placeholder.

Either remove it or use @testing-library/react with render() and fireEvent to verify the actual onStop handler wiring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx` around
lines 69 - 74, The placeholder test "vi noop sanity (placeholder for future
onStop handler verification)" provides no value—either delete this test or
replace it with a real interaction test: use `@testing-library/react`'s render()
instead of renderToStaticMarkup via renderView, locate the stop control in the
rendered output (e.g., by role/text), call fireEvent.click or userEvent.click,
and assert that the onStop mock (created by vi.fn()) was called; reference the
existing helpers make, renderView and the onStop mock when making the change.

60-67: ⚡ Quick win

Consider more robust assertions for styling verification.

The test counts bg-accent occurrences in the static HTML string, which tightly couples the test to Tailwind implementation details. This will break if class names change or if the CSS approach is refactored (e.g., CSS-in-JS, CSS modules).

Consider using @testing-library/react with render() instead of renderToStaticMarkup to verify computed styles or DOM structure more robustly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx` around
lines 60 - 67, The test should stop counting occurrences in the HTML string and
instead render the component with `@testing-library/react` and assert DOM/classes
directly: update renderView to use render() (from `@testing-library/react`) rather
than renderToStaticMarkup, render the rows created by make({ id }) and then
query the active row (e.g., getByText or a data-testid on the row) and assert
the element hasClass 'bg-accent' (use expect(element).toHaveClass('bg-accent'))
and that a non-active row does not have the bare class; this makes the test
resilient to changes in static markup or CSS build details.
apps/web/src/components/detectedServers/DetectedServerRow.test.tsx (3)

60-61: 💤 Low value

Regex assumes attribute ordering in static markup.

The regex /aria-label="Stop"[^>]* disabled=""/ requires aria-label to appear before disabled in the HTML. While renderToStaticMarkup currently has consistent attribute ordering, this assumption is fragile.

Consider a more robust approach like:

  • Checking for both attributes independently
  • Using a lightweight HTML parser
  • Switching to @testing-library/react which provides toBeDisabled() matcher
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/detectedServers/DetectedServerRow.test.tsx` around
lines 60 - 61, The helper stopBtnDisabled currently uses a fragile regex that
assumes aria-label appears before disabled; update the test to locate the
element and check the disabled state instead of relying on attribute order —
replace the stopBtnDisabled(regex) approach by rendering the component
(renderToStaticMarkup or better, use `@testing-library/react`'s render) and query
the element with aria-label "Stop" (e.g., getByLabelText/queryByLabelText) then
assert its disabled property or use the toBeDisabled() matcher; alternatively,
if you must work with static markup, parse markup to find the element and check
for the presence of the disabled attribute independently of attribute order
(modify the stopBtnDisabled function to test for both aria-label="Stop" and
disabled separately).

75-82: ⚡ Quick win

Consider more robust assertions for styling verification.

Counting bg-accent occurrences tightly couples the test to Tailwind class implementation. This mirrors the same brittleness in DetectedServersPanel.test.tsx. Consider using @testing-library/react with render() to verify computed styles or DOM structure instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/detectedServers/DetectedServerRow.test.tsx` around
lines 75 - 82, The test in DetectedServerRow.test.tsx currently uses a brittle
regex count on renderRow(...) output; update the test to render the component
with `@testing-library/react` (or make renderRow return the render container) and
assert against the DOM/classList instead of string matches: locate the renderRow
helper and make it return the render() result or container, then in the test use
screen/getByRole or container.querySelector to find the row/button element
produced by make(...) and assert element.classList.contains('bg-accent') for the
active case and not for the inactive case (or assert exact classList length if
you need to verify order), replacing the regex expectations with these DOM-based
assertions.

32-44: ⚡ Quick win

Consider testing visual behavior rather than Tailwind class names.

The test directly asserts Tailwind class strings (e.g., bg-blue-500/20), coupling tests to implementation details. If styling moves to CSS-in-JS, CSS modules, or class names change, these tests will break despite correct visual output.

For a more robust approach, consider using @testing-library/react to verify computed styles or semantic attributes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/detectedServers/DetectedServerRow.test.tsx` around
lines 32 - 44, The test DetectedServerRow.test.tsx is brittle because it asserts
Tailwind class strings; update the it.each block that calls renderRow(make({
status })) to assert visual/semantic output instead of class names: locate
DetectedServerRow (and helper renderRow/make) and change the assertions to query
the status pill element (by role/text/data-testid) and verify its computed style
(e.g., backgroundColor via window.getComputedStyle) or a semantic attribute
(aria-label or title) that reflects the status; alternatively add a stable
data-testid on the pill in DetectedServerRow and assert computed styles or
status text so tests don't depend on specific Tailwind class names.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/components/detectedServers/DetectedServerRow.test.tsx`:
- Around line 13-14: The tests currently bypass type safety by double-casting
Date objects for the DetectedServer properties startedAt and lastSeenAt; replace
the "new Date() as unknown as DetectedServer['startedAt']" and similar with
properly typed serialized date strings (e.g., use new Date().toISOString() or a
literal ISO string) so the values match DetectedServer's expected type rather
than forcing a Date through a double cast; mirror the same change made in
DetectedServersPanel.test.tsx and update any assertions that expect ISO strings
if necessary.

In `@apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx`:
- Around line 13-14: The test uses a double cast to force new Date() into
DetectedServer["startedAt"] and ["lastSeenAt"]; instead, inspect the
DetectedServer type in
apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx and
replace the unsafe "as unknown as" casts with correctly typed values: if the
fields are Date, assign new Date() directly (or new Date(timestamp) for
deterministic tests); if they are serialized strings, assign new
Date().toISOString() (or a fixed ISO string) for both startedAt and lastSeenAt
to preserve type safety and avoid masking type mismatches.

---

Nitpick comments:
In `@apps/web/src/components/detectedServers/DetectedServerRow.test.tsx`:
- Around line 60-61: The helper stopBtnDisabled currently uses a fragile regex
that assumes aria-label appears before disabled; update the test to locate the
element and check the disabled state instead of relying on attribute order —
replace the stopBtnDisabled(regex) approach by rendering the component
(renderToStaticMarkup or better, use `@testing-library/react`'s render) and query
the element with aria-label "Stop" (e.g., getByLabelText/queryByLabelText) then
assert its disabled property or use the toBeDisabled() matcher; alternatively,
if you must work with static markup, parse markup to find the element and check
for the presence of the disabled attribute independently of attribute order
(modify the stopBtnDisabled function to test for both aria-label="Stop" and
disabled separately).
- Around line 75-82: The test in DetectedServerRow.test.tsx currently uses a
brittle regex count on renderRow(...) output; update the test to render the
component with `@testing-library/react` (or make renderRow return the render
container) and assert against the DOM/classList instead of string matches:
locate the renderRow helper and make it return the render() result or container,
then in the test use screen/getByRole or container.querySelector to find the
row/button element produced by make(...) and assert
element.classList.contains('bg-accent') for the active case and not for the
inactive case (or assert exact classList length if you need to verify order),
replacing the regex expectations with these DOM-based assertions.
- Around line 32-44: The test DetectedServerRow.test.tsx is brittle because it
asserts Tailwind class strings; update the it.each block that calls
renderRow(make({ status })) to assert visual/semantic output instead of class
names: locate DetectedServerRow (and helper renderRow/make) and change the
assertions to query the status pill element (by role/text/data-testid) and
verify its computed style (e.g., backgroundColor via window.getComputedStyle) or
a semantic attribute (aria-label or title) that reflects the status;
alternatively add a stable data-testid on the pill in DetectedServerRow and
assert computed styles or status text so tests don't depend on specific Tailwind
class names.

In `@apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx`:
- Around line 69-74: The placeholder test "vi noop sanity (placeholder for
future onStop handler verification)" provides no value—either delete this test
or replace it with a real interaction test: use `@testing-library/react`'s
render() instead of renderToStaticMarkup via renderView, locate the stop control
in the rendered output (e.g., by role/text), call fireEvent.click or
userEvent.click, and assert that the onStop mock (created by vi.fn()) was
called; reference the existing helpers make, renderView and the onStop mock when
making the change.
- Around line 60-67: The test should stop counting occurrences in the HTML
string and instead render the component with `@testing-library/react` and assert
DOM/classes directly: update renderView to use render() (from
`@testing-library/react`) rather than renderToStaticMarkup, render the rows
created by make({ id }) and then query the active row (e.g., getByText or a
data-testid on the row) and assert the element hasClass 'bg-accent' (use
expect(element).toHaveClass('bg-accent')) and that a non-active row does not
have the bare class; this makes the test resilient to changes in static markup
or CSS build details.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f2b5c980-004e-4b60-acea-370ef20b84ce

📥 Commits

Reviewing files that changed from the base of the PR and between 469936e and 05aa30d.

⛔ Files ignored due to path filters (1)
  • .claude/scheduled_tasks.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • apps/server/integration/detectedServersCodex.integration.test.ts
  • apps/server/src/detectedServers/DetectedServersIngress.test.ts
  • apps/server/src/detectedServers/Handlers.test.ts
  • apps/server/src/detectedServers/Layers/DetectedServersIngress.ts
  • apps/web/src/components/BranchToolbar.tsx
  • apps/web/src/components/detectedServers/DetectedServerRow.test.tsx
  • apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx
  • apps/web/src/hooks/useDetectedServersSubscription.test.ts
  • apps/web/src/hooks/useDetectedServersSubscription.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/server/integration/detectedServersCodex.integration.test.ts
  • apps/web/src/components/BranchToolbar.tsx
  • apps/server/src/detectedServers/Handlers.test.ts
  • apps/server/src/detectedServers/Layers/DetectedServersIngress.ts

Comment on lines +13 to +14
startedAt: new Date() as unknown as DetectedServer["startedAt"],
lastSeenAt: new Date() as unknown as DetectedServer["lastSeenAt"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace double type casting with properly typed date values.

Same issue as in DetectedServersPanel.test.tsx: the as unknown as double casting bypasses type safety. Use proper date serialization (e.g., .toISOString()) instead of forcing Date objects into fields that likely expect strings.

🔧 Proposed fix
-  startedAt: new Date() as unknown as DetectedServer["startedAt"],
-  lastSeenAt: new Date() as unknown as DetectedServer["lastSeenAt"],
+  startedAt: new Date().toISOString() as DetectedServer["startedAt"],
+  lastSeenAt: new Date().toISOString() as DetectedServer["lastSeenAt"],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
startedAt: new Date() as unknown as DetectedServer["startedAt"],
lastSeenAt: new Date() as unknown as DetectedServer["lastSeenAt"],
startedAt: new Date().toISOString() as DetectedServer["startedAt"],
lastSeenAt: new Date().toISOString() as DetectedServer["lastSeenAt"],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/detectedServers/DetectedServerRow.test.tsx` around
lines 13 - 14, The tests currently bypass type safety by double-casting Date
objects for the DetectedServer properties startedAt and lastSeenAt; replace the
"new Date() as unknown as DetectedServer['startedAt']" and similar with properly
typed serialized date strings (e.g., use new Date().toISOString() or a literal
ISO string) so the values match DetectedServer's expected type rather than
forcing a Date through a double cast; mirror the same change made in
DetectedServersPanel.test.tsx and update any assertions that expect ISO strings
if necessary.

Comment on lines +13 to +14
startedAt: new Date() as unknown as DetectedServer["startedAt"],
lastSeenAt: new Date() as unknown as DetectedServer["lastSeenAt"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace double type casting with properly typed date values.

The as unknown as double casting bypasses TypeScript's type safety and suggests DetectedServer["startedAt"] and DetectedServer["lastSeenAt"] are not Date objects (likely serialized strings). This could mask type mismatches and cause issues if the contract expects a specific string format.

🔧 Proposed fix to use proper date serialization
 const make = (overrides: Partial<DetectedServer>): DetectedServer => ({
   id: "s",
   threadId: "t",
   source: "pty",
   framework: "vite",
   status: "live",
   url: "http://localhost:5173",
-  startedAt: new Date() as unknown as DetectedServer["startedAt"],
-  lastSeenAt: new Date() as unknown as DetectedServer["lastSeenAt"],
+  startedAt: new Date().toISOString() as DetectedServer["startedAt"],
+  lastSeenAt: new Date().toISOString() as DetectedServer["lastSeenAt"],
   ...overrides,
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
startedAt: new Date() as unknown as DetectedServer["startedAt"],
lastSeenAt: new Date() as unknown as DetectedServer["lastSeenAt"],
const make = (overrides: Partial<DetectedServer>): DetectedServer => ({
id: "s",
threadId: "t",
source: "pty",
framework: "vite",
status: "live",
url: "http://localhost:5173",
startedAt: new Date().toISOString() as DetectedServer["startedAt"],
lastSeenAt: new Date().toISOString() as DetectedServer["lastSeenAt"],
...overrides,
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx` around
lines 13 - 14, The test uses a double cast to force new Date() into
DetectedServer["startedAt"] and ["lastSeenAt"]; instead, inspect the
DetectedServer type in
apps/web/src/components/detectedServers/DetectedServersPanel.test.tsx and
replace the unsafe "as unknown as" casts with correctly typed values: if the
fields are Date, assign new Date() directly (or new Date(timestamp) for
deterministic tests); if they are serialized strings, assign new
Date().toISOString() (or a fixed ISO string) for both startedAt and lastSeenAt
to preserve type safety and avoid masking type mismatches.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants